Designing With Types
F# を使って、型をどのように設計に取り入れるかという話を書いたシリーズ
ここでいう設計のスコープはアーキテクチャレベルではなく、もっと詳細なクラスの実装やドメインモデリングの部分
本書の中では、以下のように書いてる
The series will be focused on the "micro level" of design. That is, working at the lowest level of indivisual types and functions
内容と目次はこんな感じ
Making design more transparent and improving correctness
Adding meaning to primitive types
Encoding business logic in types
Gaining deeper insight into the domain
Using state machines to ensure correctness
Adding more semantic information to a primitive type
Working with integers and dates safely
A before and after comparison
F# は読みづらいので Kotlin で直して書く
Summary
大雑把には、3つのテーマを話している (3つの分類は自分で勝手にわけた)
まずは、以下のような class を想定する
code:kotlin
data class Contact(
val firstName: String,
val middleInitial: String,
val lastName: String,
val emailAddress: String,
// emailAddress がユーザーによって確認済みであれば true
val isEmailVerified: Boolean,
val address1: String,
val address2: String,
val city: String,
val state: String,
val zip: String,
// address service によりバリデーションが行われていれば true
val isAddressVerfied: Boolean
)
以下3つのテーマでこのクラスやこのクラスを取り巻く環境を改善していく
大きな構造は意味のある単位で分割せよ
例えば、 Contact クラスの zip と address1 は同時に必ず更新されそうである。
一方で、 emailAddress と firstName の変更は独立していそうである。
他にも isEmailVerfied は emailAddress の変更と同じタイミングで初期化されないといけなさそうでさる。
こういった意味のある単位のものは、その単位がわかるような構造にすべき
code: kotlin
data class Contact(
val name: PersonalName,
val emailContactInfo: EmailContactInfo,
val postalContactInfo: PostalContactInfo
)
data class PersonalName(
val firstName: String,
val middleInitial: String,
val lastName: String
)
data class EmailContactInfo(
val emailAddress: String,
// emailAddress がユーザーによって確認済みであれば true
val isEmailVerified: Boolean
)
data class PostalContactInfo(
val address: PostalAddress,
// address service によりバリデーションが行われていれば true
val isAddressVerfied: Boolean
)
data class PostalAddress(
val address1: String,
val address2: String,
val city: String,
val state: String,
val zip: String
)
こうすることにより、変化の単位が明示的になるし、インスタンスレベルで切り替えるみたいな機構も用意しやすくなる。
Immutability と組み合わせるとよりこの辺の旨みが増しそう
状態の表現には直和型を利用せよ
Contact クラスに「 Contact はメールアドレスもしくは住所を必ず持たないといけない」という新しい要求が出てきた場合には、このクラスで十分それが表現できているだろうか。
code: kotlin
data class Contact(
val name: PersonalName,
val emailContactInfo: EmailContactInfo,
val postalContactInfo: PostalContactInfo
)
↑では必ず メールアドレスも住所も持たないといけないので Nullable にしてみる↓
code: kotlin
data class Contact(
val name: PersonalName,
val emailContactInfo: EmailContactInfo?,
val postalContactInfo: PostalContactInfo?
)
こうすると今度は、メールアドレスも住所も持たない状態を作れてしまうという問題を抱えることになった。
よくよく考えると要求上存在する状態は以下の3つしかないことに気づく
メールアドレスのみを持っている
住所だけを持っている
メールアドレスと住所を持っている
ここで直和型の登場である。
code:kotlin
// ContactInfo は、 EmailOnly | PostOnly | EmailAndPost しか存在しないことを表現してる
sealed class ContactInfo
data class EmailOnly(
val emailContactInfo: EmailContactInfo
): ContactInfo()
data class PostOnly(
val postalContactInfo: PostalContactInfo
): ContactInfo()
data class EmailAndPost(
val emailContactInfo: EmailContactInfo,
val postalContactInfo: PostalContactInfo
): ContactInfo()
これで明示的にも論理的にも3つの状態しか取れないことを表現できた
さらに状態遷移について考えると、この直和型が設計としてよいことがわかる
EmailContactInfo は、 isEmailVerfied というフラグで状態を管理している。
code:kotlin
data class EmailContactInfo(
val emailAddress: String,
// emailAddress がユーザーによって確認済みであれば true
val isEmailVerified: Boolean
)
フラグでの状態管理は、2つの状態が存在するということでもあり、ここにも直和型が使える。
今回の場合には、 VerifiedData と UnverfiedData の2つの型を作る
code: kotlin
sealed class EmailContactInfo
data class VerifiedData(
val emailAddress: String,
val verifiedDateTime: LocalDateTime
): EmailContactInfo()
data class UnverfiedData(
val emailAddress: String
): EmailContactInfo()
これで ContactInfo と同様に2つの状態を表現することができた。
さらにこの2つの状態には状態の遷移があることに気づく。
初期作成 -> UnverfiedData -> VerifiedData
という形で一方通行な状態遷移がある。
こういった状態遷移の State Machine を 直和型 とパターンマッチに相当するような言語機能で実現することができる
※ Kotlin の場合には、 when式が sealed class の網羅性を保証してくれるのでそれを利用する。
code:kotlin
// 初期状態
fun create(emailAddress: String): EmailContactInfo {
return UnverfiedData(emailAddress)
}
// 状態遷移関数
fun verfied(emailContactInfo: EmailContactInfo, now: LocalDateTime): EmailContactInfo {
// when で return type が確定していると全てのパターンを網羅する必要がある
return when(emailContactInfo) {
is VerifiedData -> emailContactInfo // そのまま返す
is UnverfiedData -> VerifiedData(emailContactInfo.emailAddress, now)
}
}
このような実装の場合には、新たに EmailContactInfo が増えた場合にはコンパイルエラーを引き起こすため安全で明示的になる。
こういった直和型の適応は、フラグ管理だけではなく、同じように switch/case や if での条件判定など幅広いところに適用できるパターン。
Primitive型ではなく限定した型を使う
code: kotlin
data class PersonalName(
val firstName: String,
val middleInitial: String,
val lastName: String
)
firstName を String としているが、これは本当にStringなのだろうか。
ビジネスドメイン観点から考えると、例えば、firstName の最大文字数は100かもしれない。タブや改行コードなどが入ってはいけないかもしれない。などなどよく考えると制約の観点でStringよりも狭いことがわかる。
こういった制約を満たす方法として、例えばDBヘの書き込みの直前に assertion などをすることもできるが、そうするとバリデーションロジックがいたるところに点在してしまうので使うところではなく作るところ (Construct時) で保証ですることは一般的によいプラクティスである。
code: kotlin
data class String100(
val s: String
) {
// イメージ
init { require(s.length <= 100) }
}
data class String50(
val s: String
) {
// イメージ
init { require(s.length <= 50) }
}
しかしこのままだと String100 と String50 は比較などができなくなってしまうため包括的な Interface を用意するなどが必要になったりして、割とこのあたりはどこまでやるかは難しい。
また別のケースとして、ショッピングカートのアプリで OrderId と CustomerId がどちらも Int な時にそれぞれを明確に型として区別して比較や演算などを制限することもできる。
このあたりは、ドメインとして本当にこの辺が重要であるということが明確な場合に使うなど用法要領が大事。